C#网络编程

51单片机串口编程

作者:陈广
日期:2020-7-10


单片机课程到了实训周,实训的题目定为 51 单片机串口编程。为方便学习,专门写一篇文章介绍 51 单片机的串口编程。虽然可以使用 Proteus 来模拟串口,但相当麻烦,用起来也很不顺手,最好是有一块开发板。经过 N 次改版,我设计的 51 开发板总算是成型了。下图是打印的第五块板,第一块板画了 3 次才定板。其中的心酸和曲折真是一言难尽,即使是这第五版也还是有问题,后面会讲。

图 1: STC89C52 单片机开发板

特殊功能寄存器的配置

STC89C52 单片机内置了对串口的支持,我们在学习单片机中断时,已经了解了串口是有一个串断号的。要进行串口编程,实际上也就是针对寄存器进行配置即可。我这里只针对我需要使用的地方进行讲解,并不完整,详细配置可参考数据手册或上网看视频或文章。这方面的资料实在是太多,我没必要再讲一遍。

首先要配置的是 SCON 寄存器。

图 2:SCON 寄存器
  • 最高 2 位 SM0 和 SM1 决定了串口通信模式,我们一般只会使用模式 1,即 SM0=0,SM1=1。
  • REN 为使能串口接收,我们需要使用串口接收数据时,需要将此位置 1。
  • TI 为发送中断标志位,当串口向外发送数据结束时,此位会自动变为 1,此时需要使用软件将其清 0,方可进行下一次发送。
  • RI 为接收中断标志位,当接收数据结束时,此位会自动变为 1,此时需要使用软件将其清0,方可进行下一次接收。

如果需要使用中断进行数据的收发,则需要配置中断允许寄存器IE。

图 3:IE 寄存器

如上图所示,ES 位为串口中断使能,需要置 1。另外,STC89C52RC 单片机使用定时器 1 实现串口通信。换句话说,如果使用了单片机内置的串口通信功能,则不能再将定时器 T1 用在其它地方。由于使用了 T1,需要将此寄存器的 ET1 位置 1,以使能 T1。

最后,启动串口通信,就是启动 T1,而启动 T1,就是将寄存器 TR1 置 1。

数据的发送和接收只需访问 SBUF 寄存器即可,记住,每次只能发送或接收一个字节,这和上位机可以发送和接收多个字节的概念是不一样的。奇葩的是接收和发送共用一个缓存,即给 SBUF 赋值就是发送数据,读取 SBUF 的值就是接收数据。

波特率的配置

波特率的配置要看数据手册本配置,比较麻烦,幸好有现成的公式,只需配置 T1 的计数器即可,公式为:

TH1 = TL1 = 256 - 晶振值/12/2/16/波特率

一般 51 单片机的时钟频率为 11059200,如果我们使用 9600 波特率,那么可以计算出:

TH1 = TL1 = 256 - 11059200/12/2/16/9600
TH1 = TL1 = 253 = 0xFD

如果时针频率为 12M,则使用 9600 波特率的结果为:

TH1 = TL1 = 256 - 12000000/12/2/16/9600 ≈ 252.745 ≈ 253
TH1 = TL1 = 0xFD

我这块开发板用的是 12M 晶振,结果杯具了,接收到的数据不正确。直到这一刻,我才知道为什么会有频率为 11059200 这样的奇葩晶振。之前参考别人电路图,别人用 12M,我觉得整数挺好,也跟着用,结果踩到坑了!没办法,板子打出来给学生用了,得想办法补救。降频,使用 2400 波特率:

TH1 = TL1 = 256 - 12000000/12/2/16/2400 ≈ 242.979 ≈ 243 = 0xF3

幸好 2400 波特率计算出的数字非常接近整数,只能使用这个波特率了。实践证明,使用此波特率可以正确地接收数据。

数据的接收

下面我们编写程序从上位机接收数据,做一个简单的,上位机通过串口向单片机一次只发送一个字节的数据,单片机收到数据后,将其以16进制的形式显示在最上方数码管上。

写程序还是需要电路原理图的:

图 4:开发板电路原理图

代码如下:

#include<reg52.h>
//由于使用的是 40 IO 口的芯片,而 Keil 中只有 32 IO 口的寄存器影射文件
//所以 P4 只能自行映射,本程序用不到 P4 端口,所以不使用这句不影响程序运行
sfr P4 = 0xE8;

typedef unsigned char u8;
typedef unsigned int u16;
typedef unsigned long u32;

u8 LedChar[]= //共阳数码管真值表
{
 	0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
	0x80,0x90,0x88,0x83,0xC6,0xA1,0x86,0x8E
};
//数码管缓存,只用于上方两个数码管
u8 SegBuff[2] = {0xFF, 0xFF};  
u8 recvByte = 0; //从上位机接收到的字节
u8 displayByte = 0; //当前数码管显示的字节
//配置定时器T0,5毫秒
void ConfigTimer0()
{
   TMOD &= 0xF0; //清空T0配置
   TMOD |= 0x01;
   TH0 = 0xEC;
   TL0 = 0x78; 
   ET0 = 1;
   TR0 = 1;
}
//配置串口波特率
void ConfigUART(u16 baud)
{
	SCON = 0x50;
	TMOD &= 0x0F; //清空T1配置
	TMOD |= 0x20;
	//这里固定使用2400波特率,如果为11059200晶振,
	//则可以使用后面的公式进行计算
	TH1 = 0xF3;//256 - (12000000/12/32)/baud;
	TL1 = TH1;
	ET1 = 0;
	ES = 1;
	TR1 = 1;
}

void main()
{
	EA = 1;
	ConfigTimer0();
    ConfigUART(9600);
	while(1)
	{
		if(recvByte != displayByte)
		{
		 	displayByte = recvByte;
			SegBuff[0] = LedChar[displayByte & 0x0F];
			SegBuff[1] = LedChar[displayByte >> 4];
		}
	}
}
//串口中断服务函数
void Uart_isr() interrupt 4
{
 	if(RI)
	{
	 	RI = 0;
		recvByte = SBUF;
	}
	if(TI)
	{
	 	TI = 0;
	}
}
//定时器T0中断服务函数
void Timer0_isr() interrupt 1
{
	static u8 i = 0;
 	TH0 = 0xEC;
	TL0 = 0x78;
	P1 = 0xE8;
	P1 = P1 | i;
	P0 = SegBuff[i];
	i = (i+1) % 2;
}

一个字节的最大值为 0xFF,需要使用两个数码管来显示,此时需要一个定时器来轮流刷新。T1 已经被串口使用,只能使用 T0 了。

烧写并运行程序

由于我使用的是 Type-C 接口接的串口,旧版烧写程序无法使用,所以使用的是最新版本的烧写程序,下载地址:

stc-isp-15xx-v6.87E.zip

第一次打开时记得鼠标右键以管理员方式运行。按下图所示及说明烧写程序:

图 5:烧写程序

程序烧写完后,可以通过上位机向单片机发送数据。烧写程序自带串口助手,很方便啊,按下图所示进行操作:

图 6:向单片机发送数据

每次向单片机发送的数字都会被显示在开发板上方的数码管上,如本文第一张图片所示。

数据的发送

下面讲解单片机如何向上位机发送数据。

循环发送单个字节

数据的发送我们先来个简单的例子,热热身。单片机每隔一秒钟向上位机发送一个字节,从0发到255。

#include<reg52.h>

sfr P4 = 0xE8;

typedef unsigned char u8;
typedef unsigned int u16;
typedef unsigned long u32;

//配置定时器T0,50毫秒
void ConfigTimer0()
{
	TMOD &= 0xF0; //清空T0配置
	TMOD |= 0x01;
	TH0 = 0x4C;
	TL0 = 0x00; 
	ET0 = 1;
	TR0 = 1;
}
//配置串口波特率
void ConfigUART(u16 baud)
{
	SCON = 0x50;
	TMOD &= 0x0F; //清空T1配置
	TMOD |= 0x20;
	TH1 = 0xF3;//256 - (12000000/12/32)/baud;
	TL1 = TH1;
	ET1 = 0;
	ES = 1;
	TR1 = 1;
}

void main()
{
	EA = 1;
	ConfigTimer0();
	ConfigUART(2400);
	while(1);
}
//串口中断服务函数
void Uart_isr() interrupt 4
{
	if(RI)
	{
	 	RI = 0;
	}
	if(TI)
	{
	 	TI = 0;
	}
}
//定时器T0中断服务函数
void Timer0_isr() interrupt 1
{
	static u8 cnt = 0;
	static u8 i = 0;
 	TH0 = 0x4C;
	TL0 = 0x00;
	if(cnt >= 20) //到达1秒钟
	{   //每隔 1 秒向上位机发送数字0~255
		cnt = 0;
	 	SBUF = i; //发送数据
		i = (i+1) % 256;
	}
	cnt++;
}

烧写完程序后,打开串口助手,注意点选【接收缓冲区】下方的【HEX模式】单选按钮。观察接收到的数据。

开发板化身游戏手柄

之前是热身,让我们以最低的成本了解串口编程,现在终于可以进入正题,制作一个游戏手柄。游戏很简单,接球游戏大家都玩过吧?窗口中有一个球在运动,碰到左右上边框会反弹。下方有一块可移动的木板,当球弹到下边框时,需要控制这块木板接住这个球,否则球就会掉出窗体,游戏结束。我们需要使用单片机上的两个按键控制木板的左右移动。

假设有两个键代表左和右,大家马上想到的解决方案应该是按左键向上位机发送 1,按右键向上位机发送 2。但事情没这么简单:

  1. 当两个键同时按下的时候,你如何处理?是向左还是向右?当然,同时按下就不移动木板,那么你如何判断两个键是处于同时按下的状态呢?
  2. 当长时间按下一个键时,木板应当持续移动,直到按键弹起。如何表示按键的持续按下?当按键按下时,持续向上位机发送按键信息?这样是不是会浪费带宽?有没有更好的解决方案?

解决上述问题的方案可以有很多,但我之前做了键盘,掌握了键盘数据发送的原理,使用键盘的方案应当是最优解。

我们还是每次向上位机发送一个字节,使用最低两位来表示两个按键的状态,最低位表示左键状态,最低位为 0 表示左键处于弹起状态,为 1 表示左键处于按下状态。第 2 位表示右键状态,0 表示右键处于弹起状态,为 1 表示右键处于按下状态。此时一共有四种状态:

  1. 00(十进制 0):两个按键都处于弹起状态
  2. 01(十进制 1):左键处于按下状态,右键处于弹起状态
  3. 10(十进制 2):右键处于按下状态,左键处于弹起状态
  4. 11(十进制 3):左键和右键都处于按下状态

这种方案的好处是一方面可以很方便地表示两个键同时按下的状态;另一方面,可以很方便地表示按键连续按下的状态。比如,按下左键,发送 01,放开左键,发送 00,这期间就是按键处于连续按状的持续时间。也就是说,上位机从接收到 01 开始,就判断左键已经处于连续按下状态,直到接收到 00 为止。这种方案不但节省网络带宽,上位机处理起来还更为方便。

上代码:

#include<reg52.h>

sfr P4 = 0xE8;

typedef unsigned char u8;
typedef unsigned int u16;
typedef unsigned long u32;

u16 KeySta = 0xFFFF;//记录按键状态
//发送给上位机的键盘状态,用一个字节表示
//最低位(第1位,数字0表示)表示按键【KEY00】状态,1:按下;0:弹起
//第2位(数字3表示)表示按键【KEY03】状态,1:按下;0:弹起
u8 keyByte = 0;
u8 keyBytePrev = 0; //上一次串口发送的数据

//配置定时器T0,2毫秒
void ConfigTimer0()
{
	TMOD &= 0xF0; //清空T0配置
	TMOD |= 0x01;
	TH0 = 0xF8;
	TL0 = 0x30;
	ET0 = 1;
	TR0 = 1;
}
//配置串口波特率 2400
void ConfigUART(u16 baud)
{
	SCON = 0x50;
	TMOD &= 0x0F; //清空T1配置
	TMOD |= 0x20;
	TH1 = 0xF3; //256 - (12000000/12/32)/baud;
	TL1 = TH1;
	ET1 = 0;
	ES = 1;
	TR1 = 1;
}

void main()
{
    u16 backup = 0xFFFF;
	u16 diff;
	u8 i;
	P3 = 0xFF;
	P4 = 0xFF;
	P2 = 0xF7;
	P0 = 0xFF;
	P1 = 0xE8;

	EA = 1;
	ConfigTimer0();
	ConfigUART(2400);

	while(1)
	{
	 	if(backup != KeySta)
		{
		 	diff = backup ^ KeySta;	
			for(i=0;i<16;i++)
			{	//diff中某位为1表明此键变动
			 	if((diff & (1<<i)) != 0)
				{  
					if((backup & (1<<i)) != 0) 
					{	//表明按键由弹起变为按下状态
						if(i == 0)
						{	//第1位置1
						 	keyByte |= 0x01;	
						}
						else if(i == 3)
						{
							//第2位置1
						 	keyByte |= 0x02;
						}	
					}
					else
					{	//表明按键由按下变为弹起状态
						if(i == 0)
						{	//第1位置0
						 	keyByte &= 0xFE;	
						}
						else if(i == 3)
						{
							//第2位置0
						 	keyByte &= 0xFD;
						}
					}
				}
			}
			backup = KeySta;
			//如果按键状态发生了变化,则向上位机发送数据
			if(keyByte != keyBytePrev)
			{
			 	SBUF = keyByte;
				keyBytePrev = keyByte;
			}
		}
	}
}

//串口中断服务函数
void Uart_isr() interrupt 4
{
 	if(RI)
	{
	 	RI = 0;
	}
	if(TI)
	{
	 	TI = 0;
	}
}

void Timer0_isr() interrupt 1
{
 	static u16 keybuf[4]={0xFFFF,0xFFFF,0xFFFF,0xFFFF};
	static u8 out = 0;
	u8 i;
	u8 keyIn = P2>>4;
	u16 buf,flag=0xF000;
	TH0 = 0xF8;
	TL0 = 0x30;
	for(i=0;i<4;i++)
	{
	 	buf = keybuf[out] & flag; //取出当前键所对应的4位
		buf = (buf<<1) | ((keyIn & 1)<<((3-i)*4));//将键值移入缓冲区
		buf=buf & flag;//去掉buf左移后多出的左边那位
		keybuf[out]=(keybuf[out] & (~flag)) | buf;//buf加工好后填进数组
		keyIn = keyIn >> 1;
		if(buf == 0) //连续4次扫描为0
		{
		 	KeySta = KeySta & (~(1<<(4*out+i)));
		}
		else if(buf == flag) //连续4次扫描为1
		{
		 	KeySta = KeySta | (1<<(4*out+i));
		}
		flag = flag >> 4;
	}
	//执行下一次扫描输出
	out=(out+1)&3;
	P2=0xFF ^ (8>>out);
}

这个程序是在之前写的全键防抖程序的基础上写的,当时,为了显示自己牛B,写了个最小内存使用的防抖程序,就是使用一个16位整数表示按键状态,并用四个16位整数表示16个按键的之前32毫秒四次状态(每8毫秒记录一次),当四次状态全为 0 时,表示按键已经处于稳定的按下状态;当四次状态全为 1 时,表示按键已经处于稳定的弹起状态。

这样写其实没太大必要,那天代码翻出来我自己都看不懂了,其实用一个长度为16的字节数组存储按键连续状态即可。我写键盘代码的时候就没用这种方法。不过我也懒得改了,毕竟现成的能用,花这时间改它干啥。各位看不懂直接抄就 OK 了。

烧写程序,打开串口助手,按下开发板左上和右上按键(本开发板上标注为 KEY00 和 KEY03),观察上位机接收到的数据,通过观察,你大概就可以理解各种状态下所发送的数据。当你理解了这个程序,就可以去写键盘代码了。

接球游戏

下面来写上位机游戏,当然是使用 C#。界面很简单,拖一个 Timer 控件,Interval 属性设置为 10,也就是说每隔 0.01 秒刷一次窗体,达到 100MHz 刷新率。将窗体的 DoubleBuffered 属性设置为 true,也就是打开双缓冲。剩下的就是代码了:

using System;
using System.Drawing;
using System.IO.Ports;
using System.Windows.Forms;

namespace Game
{
    public partial class MainForm : Form
    {
        public MainForm()
        {
            InitializeComponent();
        }

        SerialPort com = new SerialPort();
        const int RECT_WIDTH = 80;//木板宽度
        const int RECT_HEIGHT = 10;//木板高度
        const int STEP_WIDTH = 5;//木板每次移动的像素
        int rectLeft = 0;//木板左边的X轴坐标
        bool beginLeft = false;
        bool beginRight = false;
        Pen pen = new Pen(Color.Blue, 2);

        private void MainForm_Load(object sender, EventArgs e)
        {
            rectLeft = ClientSize.Width / 2 - RECT_WIDTH / 2;
            com.PortName = "COM3"; //注意根据实际情况写入端口号
            com.BaudRate = 2400; //注意根据实际的波特率写入
            com.DataReceived += DataReceived;
            try
            {
                com.Open();
            }
            catch (Exception ex)
            {
                MessageBox.Show("串口打开失败:" + ex.Message, "错误信息",
                    MessageBoxButtons.OK, MessageBoxIcon.Error);
                return;
            }
            timer1.Start();
        }

        private void MainForm_Paint(object sender, PaintEventArgs e)
        {
            Graphics g = e.Graphics;
			//画木板
            Rectangle rect = new Rectangle
            (
                rectLeft,
                ClientSize.Height - RECT_HEIGHT - 5,
                RECT_WIDTH,
                RECT_HEIGHT
            );
            g.DrawRectangle(pen, rect);
        }

        private void timer1_Tick(object sender, EventArgs e)
        {
            if (beginLeft && rectLeft >= 0) //防止出左边框
            {
                rectLeft -= STEP_WIDTH; //左移
            }
            if (beginRight && rectLeft + RECT_WIDTH <= ClientSize.Width)//防止出右边框
            {
                rectLeft += STEP_WIDTH; //右移
            }
            this.Refresh();
        }

        void DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            byte[] buff = new byte[com.BytesToRead];
            com.Read(buff, 0, buff.Length);
            if (buff[0] == 1)
            {
                beginLeft = true; //开始连续左移状态
            }
            else if (buff[0] == 2)
            {
                beginRight = true; //开始连续右移状态
            }
            else
            {
                beginLeft = false;
                beginRight = false;
            }
        }
    }
}

给单片机通电,运行程序,界面如下:

图 7:运行程序

界面很简单,就一个木板,按下单片机左右键,观察木板移动情况。可以发现,当按下按键时,木板会持续移动,直到放开按钮。各位可能要问了,不是接球游戏吗?球呢?本文是写给学生实训周用的,为避免学生走太多弯路,先带着走一段,属于前导课程。我要全写完了,实训做啥?

思考题

其实就是实训内容:

  1. 完善程序,加入运动的球,完成游戏。
  2. 当球掉落出窗体时,窗体显示“GAME OVER”字样。
  3. 在单片机端加入四个按钮,分别实现如下功能:
    • 开始游戏,即按下此按钮,球开始运动。
    • 游戏暂停,即按下此按钮,球停止运动。
    • 加快球运行速度。
    • 降低球运行速度。
;

© 2018 - IOT小分队文章发布系统 v0.3